Skip to content

Bounds-check GIF palette indices in decode_gif - #9630

Draft
fjankovi wants to merge 1 commit into
pytorch:mainfrom
fjankovi:fix/gif-palette-index-bounds
Draft

Bounds-check GIF palette indices in decode_gif#9630
fjankovi wants to merge 1 commit into
pytorch:mainfrom
fjankovi:fix/gif-palette-index-bounds

Conversation

@fjankovi

Copy link
Copy Markdown

Summary

decode_gif indexes the GIF colormap in two places without checking the index against ColorCount. Both indices come straight from the file, and both palettes can hold as few as 2 entries — a 6-byte calloc — so a crafted GIF reads past the allocation and those heap bytes are written into the returned tensor as pixel data.

Both are reachable through decode_gif() and through decode_image() / read_image(), which dispatch to the GIF decoder on the GIF87a/GIF89a signature.

1. Background colour — decode_gif.cpp:94

SBackGroundColor is an unvalidated byte (0-255) from the logical screen descriptor, while SColorMap->ColorCount is 1 << ((packed & 0x07) + 1), i.e. 2-256. This is the case GIFLIB itself flags — DGifGetScreenDesc() ends with:

/*
 * No check here for whether the background color is in range for the
 * screen color map.  Possibly there should be.
 */

leaving the bound to the caller. bg fills the whole canvas on the first frame (and again on any DISPOSE_BACKGROUND frame), so the leaked bytes surface in every pixel not covered by an opaque frame pixel.

2. Per-pixel palette lookup — decode_gif.cpp:182

This one is wider. The LZW minimum code size is a separate byte, validated by DGifSetupDecompress() only as BitsPerPixel > 8 -> error, and it is independent of the colour table size taken from the packed field of the descriptor. The two are never cross-checked, so a GIF declaring a 2-entry palette and an LZW code size of 8 emits raster values up to 255 — and the index is chosen per pixel. Sweeping 0..255 across a single 16x16 frame recovers the 762 bytes following the palette in one decode.

Fix

Bound both indices. An out-of-range index is treated as absent rather than an error: an out-of-range background means no background (bg stays black), and an out-of-range raster value is skipped like a transparent pixel.

I went with lenient handling rather than STD_TORCH_CHECK deliberately — sloppy encoders do emit these, browsers and Pillow render them rather than rejecting, and test_decode_gif already notes a PIL/GIFLIB disagreement about background colour on the GIFLIB welcome2 sample. Erroring out risks regressing images that decode today. Happy to switch to a hard reject if you'd prefer that, but it's a behaviour change and would want a run against the full GIFLIB sample set.

Both checks are branch-predictable comparisons in already memory-bound loops.

Verification

I built the vendored GIFLIB plus the palette-handling logic of decode_gif into a standalone ASan harness, and ran the crafted inputs plus controls through it before and after the change:

input before after
2-colour GCT, bg=255 heap-buffer-overflow, READ of size 3 clean, background black
2-colour GCT, LZW code size 8, raster 255 heap-buffer-overflow, READ of size 3 clean, pixel skipped
2-colour GCT, bg=0 (control) [17,34,51] [17,34,51]
256-colour GCT, raster 255 (control) [255,255,255] [255,255,255]

The controls are byte-for-byte identical before and after — in particular a full 256-entry palette still resolves index 255, so valid GIFs are unaffected.

Tests

Two regression tests in test/test_image.py, alongside the existing test_decode_gif_frame_outside_canvas, each parametrised over an out-of-range case and an in-range control:

  • test_decode_gif_out_of_range_background_color
  • test_decode_gif_out_of_range_palette_index

They share a small _craft_gif helper; the crafted files are 35-816 bytes and are built inline, so no new assets. Both out-of-range cases trip ASan on the current code.

Note on disclosure

This is a memory-safety issue reachable from untrusted image bytes, so I considered the advisory route first. Filing here instead follows what happened with #9551, where the reporter went through a security advisory, got no response, and the issue was fixed after being raised publicly. Happy to move this if you'd rather handle it privately.

Related but distinct: #9551 / #9520 fixed the read_from_tensor over-read and the raster-loop OOB write in this same file. Neither touches these two lines, and they're still unbounded on main.

🤖 Generated with Claude Code

decode_gif indexes the colormap in two places without comparing the
index against ColorCount. Both indices come straight from the file and
both palettes may hold as few as 2 entries (a 6-byte allocation), so a
crafted GIF reads past it and the bytes are written into the returned
tensor as pixel data.

The background colour is the case GIFLIB itself flags. SBackGroundColor
is an unvalidated byte (0-255) from the logical screen descriptor, and
DGifGetScreenDesc() ends with:

    /*
     * No check here for whether the background color is in range for the
     * screen color map.  Possibly there should be.
     */

leaving the bound to the caller. bg fills the whole canvas on the first
frame, so the leaked bytes surface in every pixel not covered by an
opaque frame pixel.

The per-pixel lookup is the wider one. The LZW minimum code size is a
separate byte, validated by DGifSetupDecompress() only as > 8 -> error,
and it is independent of the colour table size taken from the packed
field of the descriptor. A GIF declaring a 2-entry palette and an LZW
code size of 8 therefore emits raster values up to 255, and the index
is chosen per pixel: sweeping 0..255 across one 16x16 frame recovers
the 762 bytes following the palette in a single decode.

Both are reachable through decode_gif() and through decode_image() /
read_image(), which dispatch on the GIF87a/GIF89a signature.

Treat an out-of-range index as absent rather than raising: an
out-of-range background means no background (bg stays black), and an
out-of-range raster value is skipped like a transparent pixel. Sloppy
encoders do emit these and browsers and Pillow render them, so
rejecting the file would regress images that decode today.

Both cases are ASan heap-buffer-overflow before the change and clean
after, with valid GIFs -- including a 256-entry palette resolving
index 255 -- decoding byte-for-byte identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pytorch-bot

pytorch-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/vision/9630

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla

meta-cla Bot commented Aug 26, 2026

Copy link
Copy Markdown

Hi @fjankovi!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant